Visualizing Geographic Information

Published

June 21, 2025

In this section we will see some recipes to display geographic information (i.e. data with have some spatial context) and that can be visualized using maps and choropleths.

Here are some useful definitions:

Geographic Information
Geographic Information is data that includes spatial components — usually coordinates (latitude/longitude), geometric data (points, lines, polygons), and possibly additional attributes related to the geometric data.
Maps
Maps are visual representations of geographic information. We’ll often use a base map, which gives the base geographical context, with other spatial features (points, polygons, labels) which provides additional visual information.
Choropleth or Choropleth Map
A special type of map where geographic regions are displayed with information (often color) based on a data value, like area, population, count of objects inside the region, etc.
Layers
Layers are the components of a map, stacked to combine geographically-linked information. For example, we can create a map with a layer for the boundaries, another one for major cities, another one with labels and so on.
Shapefile
A shapefile is a widely used format for storing geographic features and their attributes. It can represent points, lines, or polygons (such as locations, roads, or boundaries). A shapefile is actually a group of files (commonly .shp, .shx, .dbf) that together describe the geometry and associated data of the features.
GeoJSON
GeoJSON is a modern, text-based format (based on JSON) for encoding geographic features and their properties. It stores geometries like points, lines, and polygons along with their attribute data in a single .geojson or .json file. It is easy to read and is widely used for web applications and data interchange.

Before we start…

Most of the data used in this section can be downloaded from the Instituto Brasileiro de Geografia e Estatística - Malha Municipal’s site. The data is stored in shapefiles, files with the same name but different file extensions that contains the coordinates for the geographic objects, projection, associated data, etc.

Files downloaded from the IBGE site are zip (compressed) files containing all files associated with that shapefile. When reading shapefiles we only need to open the .shp file – all associated files will be open and read automatically. In these examples we assume that the zip files were downloaded and stored in local folder.

Let’s see how to read a shapefile and get basic information. First let’s import all the libraries we will use in this section.

import pandas as pd
import geopandas as gpd
import json
import plotly.express as px
import plotly.colors as pc
import plotly.graph_objects as go
About Plotly

I prefer to use Plotly for visualization – there are other alternatives but I think it is more flexible and the plots and charts are interactive and visually more attractive.

Let’s read the Brazil’s states shapefile:

# Path to the .shp file.
shapefile_path = "Resources/Data/Shapefiles/BR_UF_2024.shp"
# Read shapefile
gdf = gpd.read_file(shapefile_path)

What’s in the shapefile? Let’s display its first few rows.

print(gdf.head())
  CD_UF           NM_UF SIGLA_UF CD_REGIA NM_REGIA SIGLA_RG     AREA_KM2  \
0    35       São Paulo       SP        3  Sudeste       SE   248219.485   
1    15            Pará       PA        1    Norte        N  1245828.829   
2    32  Espírito Santo       ES        3  Sudeste       SE    46074.448   
3    12            Acre       AC        1    Norte        N   164082.960   
4    13        Amazonas       AM        1    Norte        N  1558706.127   

                                            geometry  
0  MULTIPOLYGON (((-48.03541 -25.35682, -48.0355 ...  
1  MULTIPOLYGON (((-50.84599 -9.80064, -51.05801 ...  
2  MULTIPOLYGON (((-40.88336 -21.16372, -40.88345...  
3  POLYGON ((-68.39021 -11.04496, -68.39073 -11.0...  
4  POLYGON ((-67.51732 -9.56071, -67.51776 -9.560...  

For the states’ shapefile we have a dataframe with one state per record, with information on names, abbreviations of the state and region, its area and geometry – this is a set of geometric structures and coordinates that will be used to draw the data boundaries.

We can use the data in the shapefiles to do some queries (e.g. which tis the largest state in the NE region), but we don’t need to use the geometry directly – there are functions that use it.

Usually shapefiles have some type of index (in this example, CD_UF) that can be associated to an external data source to create, for example, rich choropleths.

Geometry simplification

As mentioned earlier, shapefiles store coordinates that define the shapes of geographic features. Official maps often include highly detailed polygons with a large number of coordinate points.

In this section, we’ll display maps on a computer screen. Even when zooming in, we rarely need that level of detail. Using the original, high-resolution coordinates increases memory usage and computational load — even simple tasks like rendering a map in a web page can become noticeably slow. For this reason, it’s often necessary to reduce the geometric complexity of shapefiles before displaying them.

There is a simple method that can be used to reduce the complexity of geometries in shapefiles: simplify. Here is an example of its usage:

shapefile_path = "Resources/Data/Shapefiles/SP_UF_2024.shp"
shapeSP = gpd.read_file(shapefile_path)
# Create a simplified copy.
shapeSP_simplified = shapeSP.copy()
shapeSP_simplified["geometry"] = \
  shapeSP_simplified["geometry"].simplify(tolerance=0.001, preserve_topology=False)

Please refer to shapely‘s documentation for information and example of the methods’ parameters.

Using simplify may trigger a warning in some cases, particularly when a geometry is smaller than the specified tolerance. To avoid this, you can try using a smaller tolerance value. In practice, it may take some adjustment to find an appropriate value, but for simple visualization purposes, the warning can often be safely ignored.

Warning
Note on Using Shapefiles and GeoJSON with Plotly
While shapefiles and GeoJSONs are great for working with geographic data, be aware that Plotly can struggle with large or highly detailed geometries — especially when rendering complex polygons or large datasets directly in the browser. If your maps are slow to render or fail to load, consider simplifying your geometries or using lower-resolution data.

For more advanced visualization of large-scale or high-resolution spatial data, consider using tools like Kepler.gl, Leaflet, or deck.gl, which are designed to handle large geographic datasets more efficiently. These tools are beyond the scope of this section, but worth exploring for heavy-duty mapping needs.

Simple Maps

One layer: Brazil’s boundaries

Let’s start with a very simple map, containing the coordinates for Brazil’s boundaries. Let’s read the shapefile:

# Path to the .shp file.
shapefile_path = "Resources/Data/Shapefiles/BR_Pais_2024.shp"
# Read shapefile
shapeBR_Raw = gpd.read_file(shapefile_path)
# Simplify the geometries for faster processing and rendering!
shapeBR = shapeBR_Raw.copy()
shapeBR["geometry"] = shapeBR["geometry"].simplify(tolerance=0.001, preserve_topology=False)
# How many records do I have?
print(len(shapeBR))
1

As expected, the shapefile for Brazil contains only one record.

To display the shapefile we loaded, first we create a choropleth using the shapefile as the source of the data and a field of the shapefile as the source of the (GeoJSON) geographic coordinates. We will also use the field PAIS to set the color of the map and set the fields’ values that will appear when we hover on the map.

fig = px.choropleth(
    shapeBR,
    geojson=shapeBR.__geo_interface__,
    locations=shapeBR.index,
    color="PAIS",  
    hover_name="PAIS",
    color_discrete_sequence=["#009440"],
    custom_data=["PAIS", "AREA_KM2"],
    projection="mercator"
)

The code below sets the format of the hover message, adjusts the bounds of the figure so it will fill the whole plot area and set some values for the maps’ appearance:

fig.update_geos(fitbounds="locations", visible=False)
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Area: %{customdata[1]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_layout(
        title="Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

You can zoom and hover the map for more information!

Just for fun, let’s see what happens when we oversimplify this shapefiles’ geometries. First we create another version of the simplified shapefile, with a much larger tolerance (much simpler shapes):

# Simplify the geometries for faster processing and rendering!
shapeBR2 = shapeBR_Raw.copy()
shapeBR2["geometry"] = \
    shapeBR2["geometry"].simplify(tolerance=0.75, preserve_topology=False)

And display it:

fig = px.choropleth(
    shapeBR2,
    geojson=shapeBR2.__geo_interface__,
    locations=shapeBR2.index,
    color="PAIS",  
    color_discrete_sequence=["#009440"],
    projection="mercator"
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_traces(hovertemplate="<extra></extra>")  # disables hover
fig.update_layout(
        title="Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

The features are still recognizable even with a much larger tolerance!

One layer: Boundaries of Brazil’s States

We can reuse the code to display a map of Brazil’s states. We need only to load a different shapefile:

# Path to the .shp file.
shapefile_path = "Resources/Data/Shapefiles/BR_UF_2024.shp"
# Read shapefile
shapeUF_Raw = gpd.read_file(shapefile_path)
# Simplify the geometries for faster processing and rendering!
shapeUF = shapeUF_Raw.copy()
shapeUF["geometry"] = shapeUF["geometry"].simplify(tolerance=0.05, preserve_topology=False)
# How many records do I have?
print(len(shapeUF))
27

Let’s display it, reusing the code for the whole country. We will change the thickness of the lines.

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="CD_UF",  
    hover_name="NM_UF",
    custom_data=["NM_UF","NM_REGIA","AREA_KM2"],
    color_discrete_sequence=["#009440"],
    projection="mercator"
)
fig.update_traces(marker_line_width=2, marker_line_color="yellow")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Region %{customdata[1]}<br>" +  # Region
                  "Area: %{customdata[2]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
        title="States of Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

One layer: Boundaries of Brazil’s States (different colors per state)

In the previous example all the states were displayed with the same color. Let’s see how we can show each one in a different color:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="CD_UF",  
    hover_name="NM_UF",
    custom_data=["NM_UF","NM_REGIA","AREA_KM2"],    
    color_discrete_sequence=px.colors.qualitative.Alphabet,
    projection="mercator"
)
fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Region %{customdata[1]}<br>" +  # Region
                  "Area: %{customdata[2]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
        title="States of Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

We can create discrete color palettes from continuous ones with this:

def my_colorscale(n, scale='Rainbow'):
    return pc.sample_colorscale(pc.get_colorscale(scale), [i / (n - 1) for i in range(n)])

And use it to plot the maps:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="CD_UF",  
    hover_name="NM_UF",
    custom_data=["NM_UF","NM_REGIA","AREA_KM2"],    
    color_discrete_sequence=my_colorscale(27,'Viridis'), 
    projection="mercator"
)
fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Region %{customdata[1]}<br>" +  # Region
                  "Area: %{customdata[2]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
        title="States of Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

One layer: Boundaries of Brazil’s States (colors based on area)

Let’s create a simple choropleth by using the information about each state area to assign a color to it. If the information is already associated to the dataframe of the shapefile it is just a case of selecting the field as the color and using a proper continuous scale:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="AREA_KM2",  
    hover_name="NM_UF",
    custom_data=["NM_UF","NM_REGIA","AREA_KM2"],    
    color_continuous_scale='YlGnBu',
    projection="mercator"
)
fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Region %{customdata[1]}<br>" +  # Region
                  "Area: %{customdata[2]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
        title="States of Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0},
        coloraxis_colorbar=dict(
           title="Área (km²)",  
           tickformat=".0f",    
           lenmode="pixels", len=450,  
           thickness=20          
        )
    )

One layer: Boundaries of Brazil’s States (conditional coloring)

Eventually we would like to highlight some polygons on the choropleth based on conditional information. This is easy to do if we create another column on the dataframe that will be used as a filter and that will create a category associated to each row.

Let’s see a simple example: I want to annotate all states in the shapefile with a field that will indicate if the state is on the North region. Here’s the code to do this:

shapeUF["isNorth"] = (shapeUF["NM_REGIA"] == "Norte").map({True: "Yes", False: "No"})

This seems redundant since we already have a field that indicates that the state is on the North region, but creating an additional field will give us more flexibility later.

The second step is to create a colormap that will be used when creating the cloropleth. The colormap must associate a color to each possible value of our filter column:

map_colors = {"Yes": "#009440","No": "#dddddd"}

Now we can plot the map, using basically the same approach as in other examples on this section:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="isNorth", 
    hover_name="NM_UF",
    custom_data=["NM_UF", "NM_REGIA", "AREA_KM2"],
    color_discrete_map=map_colors,
    projection="mercator"
)

fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  
                  "Region %{customdata[1]}<br>" +  
                  "Area: %{customdata[2]:,.0f} km²<br>" +
                  "<extra></extra>"
)

fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
    title="States of the North Region of Brazil",
    showlegend=False,
    margin={"r":0,"t":40,"l":0,"b":0}
)

It is easy to create more complex color rules using the data that is already part of the shapefile’s dataframe. Here is an example of a function that will return NE for states in the northeast, SE for regions in the southeast but only if the states’ area is larger than 100.000km². The function will return Other for states that does not match these criteria.

def mark_larger(row):
    area = row["AREA_KM2"]
    region = row["NM_REGIA"]
    if (area > 100000):
        if region == "Nordeste":
            return "NE"
        elif region == "Sudeste":
            return "SE"
        else:
            return "Other"
    else:   
        return "Other"        

Now we can create a new column on the dataframe to represent the category of the state, accordingly to the function we created:

shapeUF["Category"] = shapeUF.apply(mark_larger, axis=1)

We need a map for the colors for each category:

map_colors = {"NE": "#0D7DBD","SE": "#F79322","Other": "#dddddd"}

Now we can plot the map:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="Category", 
    hover_name="NM_UF",
    custom_data=["NM_UF", "NM_REGIA", "AREA_KM2"],
    color_discrete_map=map_colors,
    projection="mercator"
)

fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  
                  "Region %{customdata[1]}<br>" +  
                  "Area: %{customdata[2]:,.0f} km²<br>" +
                  "<extra></extra>"
)

fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
    title="Larger States in the Northeast and Southeast Regions of Brazil",
    showlegend=False,
    margin={"r":0,"t":40,"l":0,"b":0}
)

Two layers: Boundaries of Brazil and States

Eventually we will need to plot more than one layer in a single map. When using plotly the trick is to start by the base or most important layer (usually the one with the data, and the one for which we want to have the hover function) and add other layers as traces.

Here’s how to create the base layer:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="CD_UF",  
    hover_name="NM_UF",
    custom_data=["NM_UF", "NM_REGIA", "AREA_KM2"],
    color_discrete_sequence=["#009440"],
    projection="mercator",
)

We can adjust some rendering options but we need to assign the update to the dummy vatiable _ to avoid the display of a temporary map:

_ = fig.update_traces(marker_line_width=1, marker_line_color="#a0a0ff")

Now we create the other layer, that will be rendered in transparent color:

# Outline layer (entire Brazil — no color fill, just black outline)
outline = go.Choropleth(
    geojson=shapeBR.__geo_interface__,
    locations=shapeBR["PAIS"],
    featureidkey="properties.PAIS",  
    z=[0]*len(shapeBR),  # one z value per feature, any value
    showscale=False,
    marker_line_color='#808080',
    marker_line_width=3,
    colorscale=[[0, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,0)']],
    hoverinfo='skip',      # <-- disables hover
    hovertemplate=None,    # <-- disables custom hover
    name="Brasil"
)

Let’s add the trace to the base map (again using a dummy variable):

_ = fig.add_trace(outline)

Now we can set the hover information, but only for the base layer:

for trace in fig.data:
    if trace.type == 'choropleth' and trace.name != 'Brasil':
        trace.hovertemplate = (
            "<b>%{customdata[0]}</b><br>" +  # Name
            "Region: %{customdata[1]}<br>" +
            "Area: %{customdata[2]:,.0f} km²<br>" +
            "<extra></extra>"
        )

And set up boundaries and the title for the map:

fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
    title="States of Brazil",
    showlegend=False,
    margin={"r": 0, "t": 40, "l": 0, "b": 0}
)

Maps with External Data

Let’s work through a more advanced example: a two-layer map that includes external geographic data not found in the shapefile. Specifically, we’ll use aerodrome information from Brazil’s Agência Nacional de Aviação Civil (Anac) - Dados Abertos, which includes details about public and private aerodromes.

Although the data files are labeled as CSVs, they include a header metadata line and use semicolons (;) as field separators. Fortunately, we can still read them cleanly with a few extra parameters:

privados_df = pd.read_csv("Resources/Data/Shapefiles/AerodromosPrivados.csv", sep=";", 
                          encoding="latin1", skiprows=1)
publicos_df = pd.read_csv("Resources/Data/Shapefiles/AerodromosPublicos.csv", sep=";", 
                          encoding="latin1", skiprows=1)

Even though both files come from the same source, their formats differ slightly. The private aerodromes file uses commas as decimal separators in the latitude and longitude fields, so we need to convert these to proper floating-point numbers.

Since we’ll later merge the public and private datasets, we’ll also add a new column to label each entry as Privado, identifying them as private aerodromes.

# Clean and prepare the private dataset
privados = privados_df[["Nome", "Município", "UF", "LATGEOPOINT", "LONGEOPOINT"]].copy()
privados["LATGEOPOINT"] = privados["LATGEOPOINT"].astype(str).str.replace(",", ".").astype(float)
privados["LONGEOPOINT"] = privados["LONGEOPOINT"].astype(str).str.replace(",", ".").astype(float)
privados["Tipo"] = "Privado"

We also need to preprocess the data from public aerodromes. In this case, the UF field contains the full state names rather than the desired two-letter abbreviations. To prepare the data for merging with the private aerodromes dataset, we’ll map the full names to their abbreviations.

As with the private dataset, we’ll also add a Tipo column to label each entry — in this case, as Publico — to indicate that these are public aerodromes.

# Mapping of Brazilian state full names to abbreviations
estado_para_uf = {
    "Acre": "AC", "Alagoas": "AL", "Amapá": "AP", "Amazonas": "AM", "Bahia": "BA",
    "Ceará": "CE", "Distrito Federal": "DF", "Espírito Santo": "ES", "Goiás": "GO",
    "Maranhão": "MA", "Mato Grosso": "MT", "Mato Grosso do Sul": "MS", "Minas Gerais": "MG",
    "Pará": "PA", "Paraíba": "PB", "Paraná": "PR", "Pernambuco": "PE", "Piauí": "PI",
    "Rio de Janeiro": "RJ", "Rio Grande do Norte": "RN", "Rio Grande do Sul": "RS",
    "Rondônia": "RO", "Roraima": "RR", "Santa Catarina": "SC", "São Paulo": "SP",
    "Sergipe": "SE", "Tocantins": "TO"
}

# Clean and prepare the public dataset
publicos = publicos_df[["Nome", "Município", "UF", "LATGEOPOINT", "LONGEOPOINT"]].copy()
publicos["UF"] = publicos["UF"].map(estado_para_uf)
publicos["Tipo"] = "Publico"

Now that both datasets have been cleaned and standardized, we can merge the private and public aerodromes into a single DataFrame.

# Merge datasets
aerodromos = pd.concat([privados, publicos], ignore_index=True)

Next, we want to calculate the number of private, public, and total aerodromes in each state. We start by grouping the data by state (UF) and type (Tipo), and then map the resulting counts into the shapeUF GeoDataFrame using the SIGLA_UF field as the key.

# Step 1: Count aerodromes by UF and Tipo
counts = aerodromos.groupby(["UF", "Tipo"]).size().unstack(fill_value=0)

# Then map counts from the `counts` table using that column
shapeUF["Privado"] = shapeUF["SIGLA_UF"].map(counts["Privado"]).fillna(0).astype(int)
shapeUF["Publico"] = shapeUF["SIGLA_UF"].map(counts["Publico"]).fillna(0).astype(int)
shapeUF["Total"] = shapeUF["Privado"] + shapeUF["Publico"]

With the datasets cleaned and enriched, we can now build the map using three different layers. The first layer contains the state polygons, and defines a custom_data array specifying which attributes should appear in the hover tooltip.

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF["SIGLA_UF"], 
    featureidkey="properties.SIGLA_UF", 
    color="CD_UF",
    hover_name="NM_UF",
    custom_data=["NM_UF", "NM_REGIA", "AREA_KM2", "Publico", "Privado", "Total"],
    color_discrete_sequence=["#A0FFB0"],
    projection="mercator",
)

Next, we adjust the outline color and width of the polygons in the first layer. We assign the update to a dummy variable (_) so the plot is not displayed immediately.

_ = fig.update_traces(marker_line_width=1, marker_line_color="#a0a0ff")

Now we create the second layer, which will render an outline of the entire country with transparent fill and a gray border. To ensure this layer doesn’t interfere with the hover behavior of the state polygons, we explicitly disable all tooltips for it.

# Outline layer (entire Brazil — no color fill, just black outline)
outline = go.Choropleth(
    geojson=shapeBR.__geo_interface__,
    locations=shapeBR["PAIS"],
    featureidkey="properties.PAIS",  
    z=[0]*len(shapeBR), 
    showscale=False,
    marker_line_color='#808080',
    marker_line_width=3,
    colorscale=[[0, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,0)']],
    hoverinfo='skip',      # <-- disables hover
    hovertemplate=None,    # <-- disables custom hover
    name="Brasil"
)

We now add the outline layer to the base map. As before, we assign the result to a dummy variable (_) to prevent the intermediate output from being rendered.

_ = fig.add_trace(outline)

Finally, we add the third layer, containing point markers for the aerodromes based on their geographic coordinates. Since we want the hover behavior to remain focused on the state polygons, we disable tooltips for this layer as well.

# Aerodromes layer (Scattergeo for points)
points = go.Scattergeo(
    lon=aerodromos["LONGEOPOINT"],
    lat=aerodromos["LATGEOPOINT"],
    text=aerodromos["Nome"] + " (" + aerodromos["Tipo"] + ")",
    mode="markers",
    marker=dict(
        size=5,
        color=aerodromos["Tipo"].map({"Publico": "#0077CC", "Privado": "#CC3300"}),
        opacity=0.7,
        line=dict(width=0)
    ),
    name="Aeródromos",
    hoverinfo='skip',      # <-- disables hover
    hovertemplate=None,    # <-- disables custom hover
)

We add the aerodromes point layer to the map. As with previous layers, we assign the operation to a dummy variable (_) to suppress intermediate output.

_ = fig.add_trace(points)

Now we can set the hover information, but only for the states layer — which is the only one that includes custom_data. We check each trace and apply the custom hover template only if the trace has this attribute.

for trace in fig.data:
    if hasattr(trace, "customdata") and trace.customdata is not None:
        trace.hovertemplate = (
            "<b>%{customdata[0]}</b><br>" +
            "Region: %{customdata[1]}<br>" +
            "Area: %{customdata[2]:,.0f} km²<br><br>" +
            "Public: %{customdata[3]}<br>" +
            "Private: %{customdata[4]}<br>" +
            "<b>Total: %{customdata[5]}</b><br>" +
            "<extra></extra>"
        )

Finally, we adjust the geographic boundaries and layout of the map. We use fitbounds="locations" to ensure the view fits the plotted shapes, and hide the geographic base layer (visible=False). We also set the map title and remove outer margins and legends for a cleaner look.

fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
    title="Aerodromes in Brazil",
    showlegend=False,
    margin={"r": 0, "t": 40, "l": 0, "b": 0}
)